feat(data-access): add scopeType and scopeId to Opportunity model#1576
Conversation
Exposes the existing scope_type / scope_id DB columns via the JS model layer so offsite audits can tag opportunities with a brand scope. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
…d-scoped queries Adds `allByScopeId(scopeType, scopeId)` to OpportunityCollection so brand-scoped offsite LLMO opportunities can be queried directly by scope rather than iterating all site opportunities and filtering in-app. Also updates TypeScript declarations for the new method and the scope getter/setter methods added to the Opportunity model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grigoreadobe
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
- Clean, appropriately scoped PR that surfaces existing DB columns through the model layer without inventing new persistence semantics (
opportunity.schema.js:68-73). SCOPE_TYPESdefined as a static on the model and consumed viaObject.values(...)in the schema (opportunity.model.js:40-43,opportunity.schema.js:69) - new scope types update both the enum and the validator in one place.- Input validation in
allByScopeIduseshasTextto fail fast before hitting the data layer (opportunity.collection.js:37-42). - Tests cover input-validation branches and get/set accessors for both new attributes.
- JSDoc on
allByScopeIdexplains the motivation ("without going through site association"), not just the signature (opportunity.collection.js:27-35). - TypeScript declarations updated in sync with the JS additions (
index.d.ts:38-41, 56).
Issues
Critical (Must Fix)
allByScopeId queries a composite index that is not declared in the schema
Files: opportunity.schema.js:71, opportunity.collection.js:43
The schema adds scopeType and scopeId via addAttribute only - no addIndex call covers the (scopeType, scopeId) pair. allByIndexKeys({ scopeType, scopeId }) is index-driven; without a registered composite GSI the call will either throw at query time or silently fall back to a full table scan. At meaningful Opportunity table sizes, a scan means multi-second p99 latency and throttled read capacity that bleeds across the whole table.
The unit tests cannot catch this because they stub allByIndexKeys (opportunity.collection.test.js:90). The only evidence the index might exist is the undocumented data-service version bump - if that version carries the GSI, it must be stated explicitly and verified.
Fix: add the composite index declaration to the schema (matching the addIndex pattern used by other allBy* methods in this collection), and add an integration test under test/it/postgrest/ that exercises allByScopeId against a real table without stubbing.
scopeId has no format or length constraint despite being authorization-bearing
File: opportunity.schema.js:72
addAttribute('scopeId', { type: 'string' }) accepts arbitrary values: CRLF characters (log injection), multi-KB payloads (index key bloat), and non-UUID strings. Compare to primary-key handling in this codebase, which uses validate: (value) => isValidUUID(value). scopeId controls tenant attribution on opportunities, and the companion audit-worker PR feeds brandId from an SQS response directly to setScopeId. The data-access layer is the last validation checkpoint before malformed values corrupt the GSI partition.
Fix:
import { isValidUUID } from '@adobe/spacecat-shared-utils';
.addAttribute('scopeId', {
type: 'string',
validate: (value) => !value || isValidUUID(value),
})Also validate in allByScopeId before the index lookup, and add a test asserting invalid scopeId values are rejected.
Unexplained 4-major-version data-service image bump
File: test/it/postgrest/docker-compose.yml:20
Going from v1.67.8 to v5.1.1 in a PR titled "add scopeType and scopeId" is a significant undocumented change. Either:
- This is required because v5.x introduces the
scope_type/scope_idcolumns and/or the backing GSI. State this explicitly, confirm test fixtures remain compatible across 4 major versions, and document the production deployment order (data-service must precede or accompany this code release). - It is opportunistic cleanup. Move it to its own PR so its blast radius is independently reviewable.
A future bisect hitting this commit will need to unpack which of two simultaneous changes caused a regression. The image is also pinned by tag (mutable) rather than digest - pin by @sha256:... for reproducible builds.
Important (Should Fix)
No co-presence validation: scopeType and scopeId are independently optional
File: opportunity.schema.js:68-73
A record can be saved with scopeType='brand' and no scopeId, or vice versa. allByScopeId will silently miss half-scoped rows; downstream auth decisions using getScopeType() can attribute an opportunity to the wrong tenant. Add a cross-field validator enforcing both-or-neither, and add a unit test asserting a half-set scope is rejected.
TypeScript return types disagree with runtime behavior
File: index.d.ts:38-39
getScopeId(): string | null and getScopeType(): string | null declare null for absent values, but the unit tests assert to.be.undefined. Callers checking === null get a false negative on the unset case. Auth-path code relying on this falls through silently. Normalize the getters to return null, or change the TS declarations to string | undefined. Pick one and make code and types agree.
scopeType enum constraint may reject existing rows with NULL scope_type on hydration
File: opportunity.schema.js:69-71
type: Object.values(Opportunity.SCOPE_TYPES) defines a strict enum ['site', 'brand']. The PR description says the columns already exist, so historical Opportunity rows almost certainly have NULL for scope_type. Compare to lastAuditedAt (opportunity.schema.js:66) which uses validate: (value) => !value || isIsoDate(value) to explicitly tolerate absent values. The strict array-type declaration may cause hydration failures on legacy rows. Apply the same pattern or test explicitly against a DB snapshot containing NULL-scope rows.
SCOPE_TYPES.SITE creates an ambiguous dual-attribution model alongside the existing siteId field
Files: opportunity.model.js:41, opportunity.schema.js
The Opportunity already has siteId. Adding scopeType='site' with a separate scopeId creates two parallel site-association mechanisms that can disagree. Brand-scoped opportunities will also have a siteId of some primary site - queries via allBySiteId will return brand-scoped rows interleaved with site-scoped rows, silently changing the semantics of every existing allBySiteId call site.
If the current use case is brand-only, remove SITE from the enum until a concrete site-scoping use case exists. If SITE is needed now, document the invariant between siteId and scopeId for scopeType='site' and add a validator enforcing consistency.
Method name allByScopeId is misleading
Files: opportunity.collection.js:36, index.d.ts:56
The method requires both scopeType and scopeId - it is not a lookup by ID alone. allByScopeId('brand', uuid) reads as if 'brand' is a filter on an ID-based lookup, and the positional signature makes arg-flip easy. This is a public API that audit-worker services will bind against. Rename to allByScope(scopeType, scopeId) to match the existing allByAuditIdAndUpdatedAt naming convention. Update the TypeScript declaration at the same time.
setScopeType accepts any string; enum validation fires only at save time
Files: index.d.ts:41, opportunity.schema.js:69
The auto-generated setter writes to the record without validation; the schema's enum constraint fires only at save(). A bad scopeType from an SQS message fails at persist time under a different correlation ID, not at the call site. Narrow the TS type to 'site' | 'brand', or override the setter to validate against Object.values(SCOPE_TYPES) and throw immediately. Add a negative test asserting invalid values are rejected before save().
Minor (Nice to Have)
allByScopeIdJSDoc says "directly by brandId" (opportunity.collection.js:29) but the method is generic - will go stale when a second scope type is used.- Collection error messages could include the method name for easier log triage:
'allByScopeId: scopeType is required'(opportunity.collection.js:38, 41). - Setter types
setScopeId(scopeId: string)accept no null (index.d.ts:40). When someone needs to clear scope on a re-run, there is no documented path. Decide the contract now. - No test asserting that an invalid
scopeType(e.g.,'page') is rejected by the schema enum constraint. Locks in regression coverage if the enum is accidentally widened. - New accessors in
index.d.ts:38-41are inserted in the middle of an otherwise alphabetized accessor list.
Recommendations
- Add an integration test under
test/it/postgrest/that round-trips an opportunity throughsetScopeType/setScopeId-> save ->allByScopeId-> assert result. This is the single most failure-prone part of this change and no current test covers it end-to-end. - Add a
setScope(scopeType, scopeId)convenience setter that validates and sets both atomically, making it hard to produce a half-scoped record. - Consider extracting a
Scopableschema mixin (addScopeAttributes(builder)) and a sharedSCOPE_TYPESconstant. Brand scoping is described as cross-cutting - one consumer is the right moment to establish the pattern; three consumers later is a coordination project. - Document the deployment order between this code, the data-service v5.1.1 bump, and the audit-worker companion PR. If the data-service must be at v5.x for the backing index to exist, the rollout sequence is a production-safety dependency.
Assessment
Ready to merge? No, with fixes.
Reasoning: The missing composite index declaration is a runtime failure on the headline feature that unit tests cannot catch (they stub allByIndexKeys), the unexplained 4-major-version data-service bump carries unreviewed operational risk, and the scopeId format gap on an authorization-bearing field should be closed before the companion audit-worker PR ships.
| .addAttribute('scopeType', { | ||
| type: Object.values(Opportunity.SCOPE_TYPES), | ||
| }) | ||
| .addAttribute('scopeId', { |
There was a problem hiding this comment.
Critical - missing composite index AND no format constraint on scopeId.
Two issues at this attribute declaration:
Missing index for allByScopeId. allByIndexKeys({ scopeType, scopeId }) requires a registered composite GSI on (scopeType, scopeId). This diff adds addAttribute only - no addIndex call. Without the index, this call throws or full-scans at runtime. The unit test stubs allByIndexKeys (opportunity.collection.test.js:90) so CI does not catch this. Add the composite index declaration to the schema and add an integration test that exercises allByScopeId against a real table.
No format constraint on scopeId. type: 'string' accepts CRLF characters (log injection), oversized payloads, and non-UUID strings. scopeId is authorization-bearing: it controls tenant attribution and the companion audit-worker PR feeds brandId from an SQS payload directly to setScopeId. Apply UUID validation consistent with how primary keys are handled in this codebase:
import { isValidUUID } from '@adobe/spacecat-shared-utils';
.addAttribute('scopeId', {
type: 'string',
validate: (value) => !value || isValidUUID(value),
})| type: 'string', | ||
| validate: (value) => !value || isIsoDate(value), | ||
| }) | ||
| .addAttribute('scopeType', { |
There was a problem hiding this comment.
Important - no co-presence validation, and strict enum may reject existing NULL rows.
Two issues at scopeType and the scopeId pair:
No co-presence enforcement. Both attributes are independently optional. A record can be saved with scopeType='brand' and no scopeId, or vice versa. allByScopeId silently misses half-scoped rows; downstream auth using getScopeType() can attribute an opportunity to the wrong tenant. Add a cross-field validator enforcing both-or-neither.
Strict enum may break hydration of legacy rows. type: Object.values(Opportunity.SCOPE_TYPES) becomes ['site', 'brand']. The PR description says these columns already exist, so historical rows almost certainly have NULL for scope_type. Compare to lastAuditedAt which uses validate: (value) => !value || isIsoDate(value) to tolerate absent values. Apply the same pattern or test against a DB snapshot containing NULL-scope rows.
| data-service: | ||
| platform: ${MYSTICAT_DATA_SERVICE_PLATFORM:-linux/amd64} | ||
| image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v1.67.8} | ||
| image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1} |
There was a problem hiding this comment.
Critical - unexplained 4-major-version image bump.
v1.67.8 to v5.1.1 is a four-major-version jump with no explanation in the PR description. Either:
- This is required because v5.x introduces the
scope_type/scope_idcolumns and/or the backing GSI. State this explicitly, confirm all test fixtures remain compatible, and document the production deployment order. - It is opportunistic cleanup - move it to its own PR so its blast radius is independently reviewable.
A future bisect on this commit will need to unpack which of two simultaneous changes caused a regression. Also: the image is pinned by tag (mutable) - pin by @sha256:... digest for reproducible builds.
| getTags(): string[]; | ||
| getTitle(): string; | ||
| getType(): string; | ||
| getScopeId(): string | null; |
There was a problem hiding this comment.
Important - TypeScript return types disagree with runtime behavior.
getScopeId(): string | null and getScopeType(): string | null declare null for the absent case, but the unit tests assert to.be.undefined. Callers checking === null get a false negative on the unset case. Auth-path code relying on this falls through silently.
Either normalize the getters to return null (preferred for JSON/DB API consistency), or change the TS declarations to string | undefined. Pick one and align code and types.
| * @param {string} scopeId - The scope entity UUID (e.g. brand UUID). | ||
| * @returns {Promise<Opportunity[]>} The matching opportunities. | ||
| */ | ||
| async allByScopeId(scopeType, scopeId) { |
There was a problem hiding this comment.
Important - method name is misleading, and two issues around validation.
Rename allByScopeId. The method requires both scopeType and scopeId - it is not a lookup by ID alone. allByScopeId('brand', uuid) reads as if 'brand' is a filter on an ID-based lookup, and the positional signature makes arg-flip easy. Rename to allByScope(scopeType, scopeId) to match the existing allByAuditIdAndUpdatedAt convention. Update index.d.ts:56 in the same change.
scopeType is not validated against the enum here. hasText only checks presence. A caller passing 'BRAND' or 'admin' passes through into allByIndexKeys. Add if (!Object.values(Opportunity.SCOPE_TYPES).includes(scopeType)) throw new Error(...) as a second line of defense, consistent with how the schema handles invalid values at write time.
setScopeType setter fires enum validation only at save() time. The auto-generated setter writes unchecked to the record. Narrow the TS type to 'site' | 'brand' or add an explicit setter override that validates immediately.
- Rename allByScopeId → allByScope (more generic naming) - Remove SCOPE_TYPES.SITE (only BRAND is used for now) - Fix scopeType attribute: use validate() instead of type enum to handle NULL rows - Add UUID validation to scopeId attribute - Add composite index declaration for scopeType+scopeId queries - Fix TypeScript return types: string | null → string | undefined - Narrow setScopeType parameter type to 'brand' - Alphabetize getters/setters in index.d.ts - Update error messages to include method name prefix - Update tests to match renamed method and removed SITE constant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grigoreadobe
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
Previously flagged issues now addressed:
- Schema index declaration added:
.addIndex({ composite: ['scopeType'] }, { composite: ['scopeId'] })matches theaddIndex(partitionKey, sortKey)pattern used elsewhere in the codebase and follows the same structure asaudit-url.schema.js. ElectroDB will routeallByIndexKeys({ scopeType, scopeId })to this GSI. scopeIdUUID validation added:validate: (value) => !value || isValidUUID(value)bringsscopeIdto the same standard as primary key attributes in this codebase.scopeTypeattribute changed from strict array-type enum to a nullable-tolerant string with avalidateguard, preventing hydration failures on historical rows with NULL scope_type.SCOPE_TYPES.SITEremoved, eliminating the ambiguous dual-attribution model withsiteId.- Method renamed from
allByScopeIdtoallByScope, resolving the misleading positional signature. - TypeScript declarations corrected:
getScopeId(): string | undefined,getScopeType(): string | undefinednow match the runtime behavior asserted in unit tests. setScopeType(scopeType: 'brand')narrowed inindex.d.ts, providing compile-time rejection of invalid scope types.- Error messages now include the method name (
'allByScope: scopeType is required'), improving log triage. - JSDoc updated to be scope-generic, will not go stale on the second scope type.
Issues
Critical (Must Fix)
Integration test still absent (packages/spacecat-shared-data-access/test/unit/models/opportunity/opportunity.collection.test.js)
The unit test for allByScope still stubs allByIndexKeys directly. This means the GSI declaration in opportunity.schema.js is never exercised - the test passes even if the index name, composite keys, or ElectroDB routing are misconfigured. The data-service image bump from v1.67.8 to v5.1.1 is presumably what adds the backing DynamoDB GSI, but there is currently zero test coverage confirming the index resolves correctly at query time.
Add an integration test under test/it/postgrest/ that:
- Creates an Opportunity with
scopeType='brand'and a validscopeIdUUID - Saves it
- Calls
allByScope('brand', scopeId)and asserts the opportunity is returned - Calls
allByScope('brand', differentUUID)and asserts an empty result
This is the only way to prove the schema declaration, data-service migration, and ElectroDB routing work together end-to-end.
Important (Should Fix)
GSI partition key creates a single-valued hot partition (packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js:80)
With scopeType as the GSI partition key, every scope-indexed opportunity in the table lands in the same DynamoDB partition ('brand'). DynamoDB cannot split a partition across nodes - this is a single write/read hotspot for the entire scoped-opportunity workload. The audit-url schema avoids this by using siteId (a UUID, high cardinality) as its GSI partition key.
The access pattern here is always "give me all opportunities for a specific brand UUID" - scopeId is the high-cardinality discriminator, not scopeType. Invert the index:
.addIndex({ composite: ['scopeId'] }, { composite: ['scopeType'] })Each brand UUID gets its own DynamoDB partition. Fix while the index is still new and no data is in production.
Co-presence validation still missing (packages/spacecat-shared-data-access/src/models/opportunity/opportunity.schema.js:74-79)
A record can still be saved with scopeType='brand' and scopeId=undefined, or vice versa. allByScope guards at query time with hasText, but the persisted data itself is not constrained. A half-scoped row will silently disappear from allByScope results and surface as unexplained attribution gaps in the brand UI.
Add a cross-field validator enforcing both-or-neither, and add unit tests asserting a half-set scope is rejected on save.
Data-service image bump unexplained; tag not pinned by digest (packages/spacecat-shared-data-access/test/it/postgrest/docker-compose.yml:20)
The bump from v1.67.8 to v5.1.1 has no explanation in the PR description or commit message of why v5.x is required - specifically whether it carries the DynamoDB migration that creates the scope_type/scope_id columns and the backing GSI. If v5.1.1 is the production deployment prerequisite for this code change, that deployment dependency must be documented so the release team knows the data-service must be at v5.x before this code is deployed.
The tag v5.1.1 is mutable - the same tag can be updated to a different image without notice, breaking reproducible CI. Pin to a digest: image: 682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service@sha256:<digest>
Minor (Nice to Have)
opportunity.collection.test.js- BothallByScopepositive tests still use'brand-uuid-123'and'brand-uuid-no-results'asscopeIdvalues. These are not valid UUIDs and are inconsistent with the UUID validation now on the schema attribute. Replace with valid UUID strings (e.g.,'a1b2c3d4-e5f6-7890-abcd-ef1234567890'). When someone reads these tests alongside the UUID validator, the mismatch is confusing.index.d.ts:37-setScopeId(scopeId: string)has no null/undefined overload. There is currently no documented path to un-scope an opportunity after it has been scoped. Decide the contract: if clearing scope is valid, addsetScopeId(scopeId: string | null): Opportunity; if not, add a comment.- No unit test asserts that an invalid
scopeTypestring (e.g.,'page') is rejected by the schemavalidateguard. Add one to lock in regression coverage.
Recommendations
- The integration test is the highest-leverage missing piece. Before it exists, neither the index declaration, the data-service version, nor the
allByScoperouting can be considered verified. Ship the IT test alongside the schema change, not as a follow-up. - Document the deployment sequence in the PR description: data-service image must be at v5.x in production before this code is deployed, since the GSI and columns do not exist in earlier versions.
- Consider a
setScope(scopeType, scopeId)convenience setter that validates and writes both atomically. With co-presence enforced only at save time, there is a window betweensetScopeTypeandsetScopeIdwhere the in-memory object is in a half-scoped state. An atomic setter eliminates that window.
Assessment
Ready to merge? No, with fixes.
Reasoning: Three prior Critical items were addressed (index declaration, UUID validation, hydration enum fix) and five Important items closed out - solid progress. The integration test absence is the remaining blocker: the index routing cannot be verified without it, and the unexplained data-service major-version bump is exactly what the IT test is supposed to validate. The GSI partition key design should also be corrected while no production data exists yet.
Next Steps
- Add the integration test for
allByScope(verifies index routing end-to-end). - Invert the GSI composite order:
scopeIdas partition key,scopeTypeas sort key. - Add co-presence validation to the
scopeIdattribute. - Document the data-service deployment prerequisite and pin the image by digest.
- Minor items are optional.
| }) | ||
| .addIndex({ composite: ['scopeType'] }, { composite: ['scopeId'] }); | ||
|
|
||
| export default schema.build(); |
There was a problem hiding this comment.
Hot partition: scopeType has only one enum value ('brand'), so this GSI partition key concentrates all scoped opportunities in a single DynamoDB partition. DynamoDB cannot split that partition as the table grows. Invert: scopeId (UUID, high cardinality) should be the partition key and scopeType the sort key:
.addIndex({ composite: ['scopeId'] }, { composite: ['scopeType'] })Fix while no production data exists yet.
| validate: (value) => !value || isValidUUID(value), | ||
| }) | ||
| .addIndex({ composite: ['scopeType'] }, { composite: ['scopeId'] }); | ||
|
|
There was a problem hiding this comment.
Co-presence gap: there is no cross-field validator enforcing that scopeType and scopeId are both set or both absent. A record saved with only one of the two will silently disappear from allByScope results. Add a validate function on scopeId that checks item.scopeType is consistent, and add unit tests for each half-set combination.
| data-service: | ||
| platform: ${MYSTICAT_DATA_SERVICE_PLATFORM:-linux/amd64} | ||
| image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v1.67.8} | ||
| image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1} |
There was a problem hiding this comment.
Unexplained bump + mutable tag: the jump from v1.67.8 to v5.1.1 needs a comment or PR description entry explaining that v5.x carries the DynamoDB migration for scope_type/scope_id columns and the backing GSI. Without this, nobody on the release team knows the data-service must be at v5.x before this code ships.
Also, tag-pinned images are mutable - the same tag can change without notice. Pin to a digest instead:
image: 682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service@sha256:<digest>- Add create() override in OpportunityCollection to enforce that scopeType and scopeId must both be set or both be absent; throws ValidationError for half-scoped records - Invert addIndex order to scopeId (higher cardinality) as partition key and scopeType as sort key for better index selectivity - Tests: cover co-presence validation branches including null item path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce validation
- IT tests: create brand-A and brand-B scoped opportunities on the same site,
assert allByScope('brand', brandA) returns only brandA's opp — proving
the scope_type/scope_id columns and index routing work end-to-end in
PostgREST (not stubbed like unit tests)
- IT test: empty result for an unknown scopeId
- IT tests: co-presence violations (scopeType-only, scopeId-only) rejected at
create time via the new collection-level guard
- Unit tests: replace non-UUID test strings with valid v4 UUIDs to match
the schema's isValidUUID validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grigoreadobe
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
Previously flagged issues now addressed:
- Integration test gap closed:
test/it/opportunity/opportunity.test.js:385-444creates real records and round-trips through the GSI end-to-end - not a stub. The two-brand isolation assertion confirms the index discriminates correctly onscopeId. - GSI partition key corrected:
.addIndex({ composite: ['scopeId'] }, { composite: ['scopeType'] })puts the high-cardinality UUID as the partition key and eliminates the prior hot-partition risk. - Co-presence validation added at create time:
opportunity.collection.js:36-42correctly rejects half-scoped records usingBoolean(scopeType) !== Boolean(scopeId), handlingundefined,null, and empty string consistently. - Test UUIDs replaced with valid v4 UUIDs throughout, aligning unit tests with the schema's
isValidUUIDguard. - All four co-presence quadrants covered in unit tests (scopeType-only, scopeId-only, both-set, neither-set) plus the null-item edge case.
Issues
Critical (Must Fix)
Co-presence invariant is not enforced on update, save, or bulk-write paths
Files: opportunity.collection.js:36-42, opportunity.schema.js:70-78
The create() override guards a single entry point. Two write paths bypass it entirely:
setScopeType('brand'); await instance.save()- auto-generated setters andBaseModel.save()do not route through the collection override. A caller can callsetScopeId(undefined)on a fully-scoped record and save it; both attribute validators accept absent values (!value || ...), so the half-scoped state persists silently.updateByKeys({ opportunityId: X }, { scopeId: '<new-uuid>' })- patches directly via Electro without calling the override. This silently re-attributes the record to a different tenant.
Concrete impact: allByScope('brand', brandA) returns an opportunity now carrying scopeId=brandB, breaking tenant isolation. Authorization decisions made on allByScope reads become wrong without any validation error or log signal.
Fix (pick one - option a is simpler and stronger):
- a) Mark
scopeTypeandscopeIdasreadOnly: trueinopportunity.schema.js. Scope is an authorization boundary; treating it as immutable after creation closes all three write paths at once. - b) If scope must be mutable, add the same XOR guard to
createMany,updateByKeys, and a model-levelsavepre-flight so all paths enforce the invariant.
A unit test calling setScopeId(undefined); await save() and asserting a ValidationError would have caught this gap.
Important (Should Fix)
Data-service image bump still unexplained and unpinned by digest
File: test/it/postgrest/docker-compose.yml:20
Confirmed via file read at HEAD: the default is still ${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1}, no digest pin. This is the third review round this remains open:
- The 4-major-version jump from v1.67.8 to v5.1.1 has no explanation in the PR description or any commit message. If v5.x carries the migration that creates the
scope_type/scope_idcolumns and the backing GSI, this code cannot be deployed before that data-service version is live in each target environment. That deployment-order dependency must be stated explicitly. - A mutable tag means two CI runs at the same commit can resolve to different image content. For a 4-major-version bump, a tag repoint or registry compromise is invisible to PR review.
Fix: Pin to digest (...@sha256:<digest>) and add one sentence to the PR description explaining what v5.x provides and that it must be deployed before this code.
IT test records are not cleaned up after the allByScope suite
File: test/it/opportunity/opportunity.test.js:401-411
oppA and oppB are created with deterministic brand UUIDs and never removed. The outer afterEach at line 59 does not cover these new records. If the IT suite runs against a persistent database, repeated runs accumulate brand-scoped opportunities on siteId. The current include/not.include assertions survive accumulation, but any future test asserting a count on siteId will silently pick up these leaked records.
Fix: Add an after() hook inside the allByScope describe block calling oppA.remove() and oppB.remove(), or generate random UUIDs per run using crypto.randomUUID().
No test asserting an invalid scopeType value is rejected by the schema validate guard
File: opportunity.schema.js:72
Carried over from round 2. The schema defines validate: (value) => !value || Object.values(Opportunity.SCOPE_TYPES).includes(value) for scopeType and validate: (value) => !value || isValidUUID(value) for scopeId. Neither has a test exercising the rejection path. Without coverage, a future enum widening or accidental removal of the validate guard goes undetected until runtime.
Fix: Add two assertions - one for scopeType: 'page' (invalid enum value) and one for scopeId: 'not-a-uuid' (invalid UUID format) - each confirming a ValidationError is thrown.
Minor (Nice to Have)
opportunity.collection.js:39-ValidationErrorconstructed without entity context; convention elsewhere in the collection layer passesthisas the second argument for consistent error logging:new ValidationError('scopeType and scopeId must both be set or both be absent', this).opportunity.collection.js:37-item || {}is defensive code for a casesuper.createalready handles. The associated "handles null item gracefully" unit test validates internal delegation behavior rather than the co-presence rule itself.test/it/opportunity/opportunity.test.js:399- Test description says "returns only opportunities matching the given brandId" but the method is generic. Will go stale when a second scope type is added. Update to "returns only opportunities matching the given scope".opportunity.collection.js:38- UsinghasText()(already imported) instead ofBoolean()would match theallByScopevalidation style:if (hasText(scopeType) !== hasText(scopeId)).- No documentation that
scopeTypeandscopeIdmust be cleared together when removing scope from a record. Once the update path is addressed, the un-scoping contract should be noted in the JSDoc. - No backfill plan noted for historical rows with absent
scopeType/scopeId. These are correct as sparse-GSI non-entries, but any analytics query assuming "all opportunities for a brand are reachable viaallByScope" will miss legacy rows. A one-line note in the PR description or a tracked follow-up is sufficient.
Recommendations
- Mark
scopeTypeandscopeIdasreadOnly: true- simplest path to a fully enforced tenant boundary without requiring overrides on every mutation path. - File a follow-up to introduce a
validateRecordhook onSchemaBuilderso cross-field invariants live in the schema rather than per-collection method overrides. Reference it in a code comment so the next contributor sees the trajectory. - Document the GSI partition design rationale (high-cardinality PK =
scopeId, low-cardinality SK =scopeType) in the schema JSDoc so future index additions don't repeat the hot-partition lesson. - If scope becomes a cross-collection concept, extract a
Scopeablemixin at that point - one consumer is too early, three is too late.
Assessment
Ready to merge? No, with fixes.
Reasoning: Strong progress this round - the integration test, GSI inversion, and create-path co-presence check are all correct and close the prior blockers. The remaining issue is that co-presence enforcement is incomplete: update and save paths can silently produce the exact half-scoped records that create() refuses to write, on a field that controls tenant attribution. Either mark scope as read-only or extend the guard to all write paths before landing.
Next Steps
- Address the Critical: add
readOnly: truetoscopeType/scopeIdin the schema (preferred), or extend the co-presence check to cover update, save, and createMany paths. - Address the 3 Important items: document and pin the data-service image, add IT cleanup, and add the missing validate-guard tests.
- Minor items are optional.
…ess review feedback - Add save() override in Opportunity model to enforce that scopeType and scopeId must both be set or both be absent on every save, not only at create() time - Switch Boolean() to hasText() in collection co-presence check for correct empty-string handling - Pass `this` as second arg to ValidationError in collection, matching the standard call convention used elsewhere in the codebase - Add null/undefined overloads to setScopeId/setScopeType TypeScript declarations so applyScopeToOpportunity types correctly - Add after() cleanup hook in IT allByScope tests to remove oppA/oppB; fix test description from "brandId" to "scope" - Add docker-compose comment explaining the mutable image tag and how to override it - Add unit tests for save() co-presence (all four branches) and direct schema validate guard tests for invalid scopeType/scopeId values Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rarescheseli
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
The save() override at opportunity.model.js:51-58 is well-implemented: it uses the same hasText() XOR check and ValidationError(message, this) pattern as collection.create(), delegates cleanly to super.save(), and all four co-presence quadrants are unit-tested at opportunity.model.test.js:413-453. The pattern also aligns with how consumer.model.js handles similar invariants elsewhere in the codebase - not a new design, just a consistent extension.
Previously flagged issues now addressed:
- Round 3 Critical (save-path co-presence): the setter+save path (
setScopeType(...); save()) is now guarded. - Round 3 Important (IT cleanup):
after()hook atopportunity.test.js:401-403removesoppA/oppBvia?.remove()with.filter(Boolean)- correctly handles partial create failures. - Round 3 Important (validate-guard tests):
opportunity.collection.test.js:108-122adds direct assertions on both schema validators. Valid values, invalid enum, non-UUID, and null are all exercised. - Round 3 minors:
ValidationErrornow passesthisas entity context inopportunity.collection.js:39;hasText()replacesBoolean()at line 36; IT test description corrected from "brandId" to "scope" atopportunity.test.js:407. - The
setScopeId(scopeId: string | null | undefined)andsetScopeType(scopeType: 'brand' | null | undefined)declarations inindex.d.ts:35-36are backward-compatible relaxations that match runtime behavior and letapplyScopeToOpportunitytype-check without casts.
Issues
Important (Should Fix)
Co-presence invariant is still bypassable via bulk write paths
- Also posted as inline comment on
opportunity.collection.js
The round-3 fix established that co-presence must hold across all write paths. The new save() override covers create(item) and setScopeX(); save(). Three paths inherited from BaseCollection still reach the database without going through either guard:
updateByKeys(keys, { scopeId: '<other-brand-uuid>' })patches viaentity.patch(keys).set(...).go(), which never instantiates the model or callscreate(). A caller can also use this to re-attribute an existing opportunity to a different brand'sscopeIdwithout touchingscopeType.createMany([...])iterates raw items throughentity.put(item).params()and does not callthis.create(). Per-attribute validators run; co-presence does not.saveMany(items)/_saveMany(items)callsentity.put(updates).go()directly, bypassing model instantiation entirely.
How to fix - two options, both in-scope for this repo:
Option a (preferred): mark scopeType and scopeId as readOnly: true in opportunity.schema.js. The base model's auto-generated setters are skipped for readOnly attributes, so post-create mutation is impossible by construction. Co-presence enforcement then lives only in create(), closing all three bulk paths at once and removing the need for the save() override. This was round-3's option (a); the author chose option (b), but option (b) demonstrably requires three more overrides to be complete.
Option b: override createMany and updateByKeys on OpportunityCollection with the same hasText(scopeType) !== hasText(scopeId) guard. Note that updateByKeys must account for the post-update merged state, which requires reading the existing record - meaningfully more complex than option (a).
Data-service image still tag-pinned; PR description missing deployment prerequisite
- Also posted as inline comment on
docker-compose.yml
What the new commit fixed: the comment at lines 18-19 explains the bump rationale and the override mechanism. Useful for developers running tests locally.
What remains open: (a) the image is still pinned by mutable tag (:v5.1.1). ECR tags can be re-pushed - if v5.1.1 is ever updated upstream, CI will silently test against a different binary and git log will not identify the change. (b) The PR description still does not mention that mysticat-data-service v5.x is a deployment prerequisite. A docker-compose comment is read by engineers running tests; it is not read by engineers sequencing a deploy. If this code lands in an environment still on a pre-v5 data-service, the failure at allByScope or scope writes will not point back to this PR.
How to fix: (a) pin by digest: image: ...:v5.1.1@sha256:<digest> (Compose supports tag-plus-digest; the tag stays readable). Keep MYSTICAT_DATA_SERVICE_TAG override for local dev. (b) Add one sentence to the PR description: "Requires mysticat-data-service v5.x (adds scope_type/scope_id columns and the scopeId/scopeType GSI). Confirm target environment is on v5.x before deployment."
Missing regression test for the clear-then-save transition
- Also posted as inline comment on
opportunity.model.test.js
The four existing save() tests cover static quadrants - all starting from an unconfigured instance. The most realistic misuse pattern (a caller loads a fully-scoped record, clears one field with setScopeId(null), then saves) is not covered. The override handles this correctly - hasText(null) === false fires the XOR - but a future refactor that short-circuits on "unchanged fields" could silently break the guard with the current test set.
How to fix: add a fifth test case - pre-set instance.record.scopeType = 'brand' and instance.record.scopeId = '11111111-1111-1111-1111-111111111111', call instance.setScopeId(null), assert instance.save() rejects with the same ValidationError message.
Minor (Nice to Have)
save() ValidationError message carries no record identity
File: opportunity.model.js:52-55
The message is 'scopeType and scopeId must both be set or both be absent'. The entity instance is passed as the second arg so a handler that unpacks ValidationError.entity gets full context - but anything that logs only err.message produces an undiagnosable line in production. Including this.getId() plus the current field values in the message string is a one-line improvement.
Defensive item || {} in OpportunityCollection.create() obscures intent
File: opportunity.collection.js:34
super.create(null) would fail at the base collection level anyway. The || {} means a null item passes the co-presence check (both fields absent, hasText returns false for both) then fails one frame deeper with a generic base-class error rather than the co-presence message. Either remove || {} and rely on the base class, or add an explicit early null check with a clear ValidationError.
Recommendations
- If
readOnly: trueis adopted (option a above), thesave()override and its four unit tests can be removed, simplifying the surface. The schema validators andcreate()guard remain and are sufficient. - A one-line JSDoc note on
setScopeTypeandsetScopeIdthat both must be set or cleared together would surface the contract to consumers before they hit the runtime error. The currentnull | undefinedTS overloads imply "you can clear one."
Assessment
Ready to merge? No, with fixes.
Reasoning: The save-path co-presence fix is correct and the addressed round-3 items are solid. The co-presence invariant is now documented in three places and enforced on two of an estimated five write paths - a state that is harder to reason about than either "always enforced" or "explicitly deferred." Completing option (a) or option (b) before the companion audit-worker PR lands removes a real batch-ingestion risk without requiring significant rework.
Next Steps
- Address the bulk write gap -
readOnly: trueon both schema attributes (preferred) or overridecreateManyandupdateByKeysonOpportunityCollection. - Pin the data-service image to a digest and add a one-sentence deployment prerequisite to the PR description.
- Add the partial-clear regression test.
- Minor items are optional.
| if (hasText(scopeType) !== hasText(scopeId)) { | ||
| throw new ValidationError('scopeType and scopeId must both be set or both be absent', this); | ||
| } | ||
| return super.create(item, options); |
There was a problem hiding this comment.
This create() override guards a single write entry point. Three paths inherited from BaseCollection still bypass both this guard and the new Opportunity.save() override:
updateByKeys(keys, { scopeId: '<other-brand-uuid>' })- patches viaentity.patch(keys).set(...).go()without model instantiation. Can also re-attribute an existing opportunity to a different brand'sscopeIdwithout touchingscopeType.createMany([...])- callsentity.put(item).params()per item, does not delegate tothis.create().saveMany(items)/_saveMany(items)- callsentity.put(updates).go()directly.
Preferred fix: mark scopeType and scopeId as readOnly: true in opportunity.schema.js. Post-create mutation becomes impossible by construction, co-presence only needs to live in create(), and all three bulk paths are closed at once - without adding more overrides.
| image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v1.67.8} | ||
| # Default tag is bumped here whenever a new schema migration requires a matching data-service | ||
| # image. Override at runtime: export MYSTICAT_DATA_SERVICE_TAG=v<version> before npm run test:it. | ||
| image: ${MYSTICAT_DATA_SERVICE_REPOSITORY:-682033462621.dkr.ecr.us-east-1.amazonaws.com/mysticat-data-service}:${MYSTICAT_DATA_SERVICE_TAG:-v5.1.1} |
There was a problem hiding this comment.
The comment added in this commit explains the bump policy for developers running tests locally - that is an improvement. Two gaps remain:
- The tag
:v5.1.1is mutable. If it is ever re-pushed in ECR, CI will silently test against a different binary. Pin by digest:image: ...:v5.1.1@sha256:<digest>(Compose supports tag-plus-digest; the tag stays human-readable). - The PR description still does not note that
mysticat-data-service v5.xis a deployment prerequisite for this code change. A docker-compose comment is read by engineers running tests; it is not read by engineers sequencing a deploy. Please add one sentence to the PR body: "Requires mysticat-data-service v5.x (adds scope_type/scope_id columns and the scopeId/scopeType GSI). Confirm target environment is on v5.x before deployment."
| it('delegates to super.save() when neither scopeType nor scopeId is set', async () => { | ||
| const patcherSaveStub = stub(instance.patcher, 'save').resolves(); | ||
| await expect(instance.save()).to.be.fulfilled; | ||
| expect(patcherSaveStub.calledOnce).to.be.true; |
There was a problem hiding this comment.
The four tests here cover static quadrants (neither-set, scopeType-only, scopeId-only, both-set) all starting from an unconfigured instance. The most realistic misuse pattern is missing: loading a fully-scoped record and clearing one field before saving.
Please add a fifth case:
it('throws ValidationError when a fully-scoped record has scopeId cleared', async () => {
instance.record.scopeType = 'brand';
instance.record.scopeId = '11111111-1111-1111-1111-111111111111';
instance.setScopeId(null);
await expect(instance.save()).to.be.rejectedWith(
'scopeType and scopeId must both be set or both be absent',
);
});The override handles this correctly today (hasText(null) === false), but this test locks in the behavior against future refactors.
Add an updateByKeys override on OpportunityCollection that enforces the
scopeType/scopeId co-presence invariant. The base class patches DynamoDB
directly, bypassing the create() and save() guards already in place.
The check fires when either field appears in the update payload:
- both must be present together in the same call
- their values must satisfy co-presence (both truthy or both null/empty)
Clearing only one field (e.g. { scopeType: null } without scopeId) now
throws ValidationError, preventing ghost-scope state in re-run scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sence guard Add two tests for the case where both scopeType and scopeId keys are present in the update object but one value is non-empty and the other is null — the second throw in the updateByKeys guard (lines 64-65). These were the only uncovered lines causing the 100% coverage threshold to fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rarescheseli
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
Previously flagged issue now addressed: the updateByKeys override at opportunity.collection.js:32-56 correctly closes the bulk-write bypass path flagged in round 4. The two-step validation - key-presence check ('scopeType' in updates) before value check (hasText(...)) - handles the partial-update case and the value-mismatch case in distinct, clear branches. The 9 unit tests at opportunity.collection.test.js:116-202 cover all branches including both value-mismatch directions (scopeType null, scopeId null), clearing both fields, and no-scope-fields-in-update. The second commit to plug the uncovered throw paths was the right call.
Issues
Important (Should Fix)
createMany still bypasses the co-presence guard
The author chose option (b) - override each write path - and updateByKeys is now covered. createMany inherited from BaseCollection still calls entity.put(item).params() per item without delegating to this.create(). A bulk-ingest scenario using createMany can write half-scoped Opportunity records. Those records are unreachable via allByScope and silent from the caller's perspective - if the companion audit-worker ever uses createMany, the co-presence invariant breaks with no error signal.
Fix: add a createMany override on OpportunityCollection that applies the same hasText(scopeType) !== hasText(scopeId) check per item before delegating to super.createMany. That closes the last remaining option-b gap.
Data-service image tag still mutable; PR description still lacks deployment prerequisite
File: test/it/postgrest/docker-compose.yml:22
This has been raised in each of the previous four review rounds. Two gaps remain unchanged:
-
The image tag
v5.1.1is mutable - an ECR tag re-push silently changes what CI tests without any signal in git history. Pin to a digest:image: ...mysticat-data-service:v5.1.1@sha256:<digest>- Compose supports tag-plus-digest; the tag stays human-readable, the digest enforces the binary. -
The PR description still does not mention that mysticat-data-service v5.x is a prerequisite for this code to work in production. The docker-compose comment added in round 4 helps developers running tests locally - it does not help engineers sequencing a production deploy. One sentence in the PR body is all it takes: "Requires mysticat-data-service v5.x (adds scope_type/scope_id columns and the scopeId/scopeType GSI). Confirm target environment is on v5.x before deployment."
updateByKeys guard prevents half-scoped writes but not cross-tenant re-attribution
File: opportunity.collection.js:32-56
The new guard correctly rejects single-field updates and value-mismatch updates. But updateByKeys(keys, { scopeType: 'brand', scopeId: '<different-brand-uuid>' }) passes the co-presence check - both keys present, both values truthy - and silently re-attributes the Opportunity from its original brand tenant to a different one.
For a field that controls which brand can query an opportunity via allByScope, permitting post-creation re-attribution without any additional authorization check is a meaningful semantic gap. The readOnly: true option offered in round 4 would close this by construction. Since that path has been declined in favor of option (b), the minimum fix is a JSDoc clarification on updateByKeys explicitly stating that the guard enforces co-presence only - not scope immutability - and that scope re-attribution is an intentional capability of this API. That documents the design decision and prevents a future reader from assuming the guard is a complete tenant boundary.
Minor (Nice to Have)
Missing regression test for the clear-then-save transition
File: test/unit/models/opportunity/opportunity.model.test.js
Carried from round 4. The four save() tests cover static quadrants from an unconfigured instance. The realistic misuse pattern - pre-set instance.record.scopeType = 'brand' and instance.record.scopeId = '<uuid>', call instance.setScopeId(null), assert save() rejects - is absent. The override handles this correctly today, but this test case locks in the behavior against future refactors that short-circuit on unchanged fields.
Assessment
Ready to merge? No, with fixes.
updateByKeys is clean and well-tested - solid incremental progress on option (b). Two Important items remain before the companion audit-worker PR lands: createMany still bypasses the co-presence invariant, and the deployment prerequisite for mysticat-data-service v5.x has gone undocumented in the PR description across five review rounds. The cross-tenant re-attribution concern can be resolved with a one-line JSDoc clarification rather than a code change.
Next Steps
- Add the
createManyoverride (the remaining option-b write path). - Pin the docker-compose image by digest and add one sentence to the PR description about the v5.x data-service requirement.
- Add a one-line JSDoc note to
updateByKeysclarifying scope co-presence vs. immutability. - Add the clear-then-save regression test (minor, optional but recommended).
…gest Address the round-5 carry-forward findings from PR #1576: - Add a createMany override that runs the same co-presence guard as create() and updateByKeys(). BaseCollection.createMany previously bypassed validation, so a consumer using batch ingestion could persist half-scoped opportunities without any signal — broken GSI semantics, no audit trail. Half-scoped items now throw ValidationError before reaching the data service. - Document updateByKeys's scope-reattribution capability via JSDoc. The guard enforces co-presence but allows scope_id to change post-creation. Callers must verify authorization before mutating scope_id (it's the tenant boundary). - Support pinning the data-service image to an immutable digest via a new MYSTICAT_DATA_SERVICE_DIGEST env var. Documents the AWS ECR command to obtain it. The tag remains v5.1.1 (human-readable) and the digest, when set, prevents a re-pushed tag from silently changing the CI binary. - Add tests: createMany rejection (4 cases) and clear-then-save regression (2 cases — the most common realistic mutation pattern, missing from the suite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keep digest-pinning env var support while incorporating upstream changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rarescheseli
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
Previously flagged issues now addressed:
opportunity.collection.js:54-64:createManyoverride closes the bulk-create bypass gap from round 5. The logic mirrorscreate()exactly using the samehasText(scopeType) !== hasText(scopeId)XOR, and the JSDoc explicitly calls out which base-class bypass it closes - the kind of comment that earns its keep.opportunity.collection.js:67-69:updateByKeysJSDoc separates co-presence enforcement from tenant authorization, correctly placing the AuthZ obligation on callers. Resolves the round-5 ask cleanly.opportunity.model.test.js:455-475: both clear-then-save regression cases (scopeId cleared, scopeType cleared) cover the realistic "loaded record, partial clear" path.- PR description: now contains the explicit
mysticat-data-service v5.xdeployment prerequisite sentence.
Additional:
opportunity.collection.test.js:108-156:createManytests cover all critical branches - scopeType-only, scopeId-only, mixed-batch (valid + invalid tuple), and non-array passthrough. Thenot.have.been.calledassertions confirm the guard short-circuits before delegating to super.docker-compose.yml:${MYSTICAT_DATA_SERVICE_DIGEST:+@${MYSTICAT_DATA_SERVICE_DIGEST}}is the correct Docker Compose idiom for optional digest pinning. Additive, no syntax error when unset, well-documented with the ECR command.
Issues
Important (Should Fix)
Digest mechanism exists in docker-compose but CI never sets the variable
Files: test/it/postgrest/docker-compose.yml; .github/actions/lint-test-coverage/action.yaml
The comment at docker-compose.yml:21-23 states "CI cannot then silently consume a re-pushed tag - if ECR re-publishes v5.1.1 with a different binary, the digest mismatch causes a hard pull failure instead of silent drift." The composite lint-test-coverage action at HEAD does not set MYSTICAT_DATA_SERVICE_DIGEST, so CI still pulls the mutable :v5.1.1 tag unchanged. An ECR re-push of that tag would cause silent drift in CI exactly as before. The protection is documented but not wired in.
Fix - pick one:
- (Preferred) Add to
.github/actions/lint-test-coverage/action.yamlon the Run Integration Tests step:env: { MYSTICAT_DATA_SERVICE_DIGEST: "${{ vars.MYSTICAT_DATA_SERVICE_DIGEST }}" }, then define the repo variable. Digest rotation on a future tag bump becomes a one-line settings update. - Or commit a non-empty default digest directly to
docker-compose.ymlso it is self-pinning without requiring a CI environment change. - Or remove the "CI cannot then silently consume" language from the comment and PR description and document the mechanism as opt-in for local operators only.
saveMany is a public write path on BaseCollection not covered by the co-presence guard
File: packages/spacecat-shared-data-access/src/models/opportunity/opportunity.collection.js
BaseCollection.saveMany (inherited by OpportunityCollection) persists items via _saveMany, which calls entity.put(updates).go() directly without invoking BaseModel.save(). The Opportunity.save() co-presence guard therefore does not fire on the saveMany path. A caller doing coll.saveMany([opp]) where opp has one scope field set and the other cleared would persist a half-scoped row silently.
This path was mentioned in a round-4 inline thread but was not included in round-5's explicit Next Steps, which focused only on createMany. The PR's own description now claims "co-presence invariant enforced at every write path" - saveMany is the gap between that claim and the implementation.
Fix: add a saveMany override on OpportunityCollection mirroring the createMany pattern - iterate items, check hasText(item.getScopeType()) !== hasText(item.getScopeId()), throw ValidationError on violation, then delegate to super.saveMany.
Minor (Nice to Have)
createMany JSDoc says "Validates and bulk-creates" unconditionally but non-array inputs bypass validation
File: opportunity.collection.js:44
The implementation guards validation with if (Array.isArray(items)), so a non-array argument passes to super without co-presence checking. The test at line 151 documents this as intended. The mismatch is minor but will mislead a future reader. Tighten the JSDoc to "when items is an Array, enforces..." or drop the if guard and let for...of throw naturally on non-iterables.
null array-element branch in createMany is not exercised
File: opportunity.collection.test.js
The item || {} in the production loop handles null/undefined elements; no test exercises this path. Add one: instance.createMany([{ scopeType: 'brand', scopeId: '<uuid>' }, null]) asserting the call fulfils (null element has both fields absent, passes co-presence). Low-risk omission.
Recommendations
Extract the hasText(scopeType) !== hasText(scopeId) XOR and its error message into a private method on OpportunityCollection (e.g. #assertScopeCoPresence(scopeType, scopeId)) consumed by create, createMany, updateByKeys, and the prospective saveMany override. The same expression and error string appearing at four call sites is the threshold where a one-liner helper pays for itself.
Out of scope, worth tracking: the saveMany discovery is a concrete data point on the cost of option (b). A schema-level readOnly: true on scopeType/scopeId with a single setScope(type, id) mutator would survive future write paths on BaseCollection without requiring subclass overrides. When a second entity needs cross-field tenant invariants, this refactor will be significantly harder than it is today.
Assessment
Ready to merge? No, with fixes.
Reasoning: The two Important items are each a small, in-scope fix. Wiring the digest env var into the CI action (or committing a default digest) converts a theoretical protection into a real one. Adding a saveMany override closes the last gap in the PR's "every write path" claim.
Next Steps
- Wire
MYSTICAT_DATA_SERVICE_DIGESTinto the composite CI action, commit a default digest to docker-compose.yml, or narrow the comment and PR description to match what is actually enforced today. - Add a
saveManyoverride with the per-item co-presence guard. - Minor items are optional.
|
both issues can be deferred |
rarescheseli
left a comment
There was a problem hiding this comment.
Hey @HollywoodTonight,
Strengths
- Co-presence invariant is enforced at all guarded write paths:
create,createMany,updateByKeys, and model-levelsave. ThehasText(scopeType) !== hasText(scopeId)XOR is consistent across all four overrides, and the 6 clear-then-save regression tests lock in the guard against future refactors (opportunity.model.test.js:455-475). - GSI partition key is correct:
scopeId(UUID, high cardinality) as PK,scopeTypeas SK. The inversion from round 2 eliminates the single-partition hotspot (opportunity.schema.js:226). - Integration test at
test/it/opportunity/opportunity.test.js:385-444round-trips two brand-scoped records end-to-end through the live GSI - not stubbed. Brand A vs Brand B isolation is verified in the same test. SCOPE_TYPES = { BRAND: 'brand' }on the model is the right single source of truth for the scope vocabulary. When the vocabulary grows, one change updates both enum and validator.- PR description now contains the explicit
mysticat-data-service v5.xdeployment prerequisite - the right operational artifact for engineers sequencing the 4-PR rollout.
Issues
Minor (Nice to Have)
docker-compose comment overstates the live CI protection
test/it/postgrest/docker-compose.yml, comment block added in this PR
The comment reads: "The tag is supplemented by an immutable digest so CI cannot silently consume a re-pushed tag." This is accurate only when MYSTICAT_DATA_SERVICE_DIGEST is set. At HEAD, neither .github/actions/lint-test-coverage/action.yaml nor the main workflow sets this variable, so CI still pulls the mutable :v5.1.1 tag. The opt-in mechanism and syntax are correct; the prose describes a guarantee that isn't currently wired. Suggested reword: "When MYSTICAT_DATA_SERVICE_DIGEST is set (see below), the digest suffix prevents a re-pushed tag from silently changing the CI binary. CI action wiring tracked separately."
PR description "every write path" claim is ahead of implementation
PR body, summary section
"Co-presence invariant enforced at every write path" - saveMany (inherited from BaseCollection) is deferred. Narrowing to "enforced at create, createMany, updateByKeys, and save; saveMany tracked as a follow-up" keeps the record accurate for future readers of the PR history.
Recommendations
-
File two follow-up issues before the companion consumer PRs ship: (a) wire
MYSTICAT_DATA_SERVICE_DIGESTinto the lint-test-coverage action, or trim the compose comment to match what's actually wired; (b) add asaveManyoverride - either mirror thecreateManyloop pattern or revisit thereadOnly: trueapproach onscopeType/scopeIdto close all future bulk-write paths in one schema declaration. -
Once
saveManycoverage lands, extract thehasText(scopeType) !== hasText(scopeId)guard and its error message into a private method onOpportunityCollection. The same expression at 4+ call sites is the threshold where a one-liner helper earns its keep.
Assessment
Ready to merge? Yes.
Reasoning: All substantive findings from 6 review rounds are addressed. Both deferred items have bounded blast radii - the saveMany gap is latent and self-revealing on first bulk-caller integration test (no current caller exists); the CI digest gap affects only internal-registry test reproducibility, not production. The two Minor prose corrections above don't require another code iteration.
Next Steps
- Optional before merge: update the docker-compose comment and PR description to accurately describe what is and isn't wired today.
- Before consumer PRs ship: file follow-up issues for
saveManyoverride and CI action wiring so the deferrals don't dissolve into the backlog.
## [@adobe/spacecat-shared-data-access-v3.61.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.60.0...@adobe/spacecat-shared-data-access-v3.61.0) (2026-05-12) ### Features * **data-access:** add scopeType and scopeId to Opportunity model ([#1576](#1576)) ([3f0f8f2](3f0f8f2)), closes [#32-42](#32)
|
🎉 This PR is included in version @adobe/spacecat-shared-data-access-v3.61.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Exposes the existing
scope_type/scope_idDB columns via the JS model layer so offsite audits can tag opportunities with a brand scope, and adds the index needed to query them efficiently.What's in the PR
Opportunity.SCOPE_TYPESconstant ({ BRAND: 'brand' }) and auto-generatedgetScopeType/setScopeType/getScopeId/setScopeIdaccessors via the schema builder.scopeTypemust be one ofSCOPE_TYPES;scopeIdmust be a valid UUID; both may benull.(scopeId, scopeType)index supportingOpportunity.allByScope('brand', brandId)— the read path used byspacecat-api-servicefor the brand-specific opportunities endpoint.create()— single-item guard (chore(deps): update to latest dynamo #32-42 ofopportunity.collection.js)createMany()— per-item guard, added in this round;BaseCollection.createManypreviously bypassed validation and would persist half-scoped records without any signalupdateByKeys()— guards both key presence and value co-presence (with a JSDoc note clarifying that the guard allows scope re-attribution post-creation; callers must verify authorization before mutatingscope_id)save()(model override) — catches realistic mutation patterns (clear-then-save) thatcreate/updatepaths cannotDeployment requirements
Requires
mysticat-data-servicev5.x (adds thescope_type/scope_idcolumns and thescopeId/scopeTypeGSI). Confirm the target environment is on v5.x before deployment.The IT image tag is
v5.1.1and the docker-compose file accepts an optionalMYSTICAT_DATA_SERVICE_DIGEST=sha256:<digest>env var to pin the image immutably (CI cannot then silently consume a re-pushed tag). Resolution command:Consumers (separate PRs)
setScopeType/setScopeId.Opportunity.allByScope('brand', brandId).This PR must merge & publish first; consumers bump
@adobe/spacecat-shared-data-accessto the new version afterwards.Test plan
npm test -w packages/spacecat-shared-data-access— 1940 passing, 100% line/branch/statement coverage.npm run test:it -w packages/spacecat-shared-data-access— round-trip integration test forallByScope(test/it/opportunity/opportunity.test.js:385-444).Related Issues